Skip to content

[OMNIML-6083] Write a sharded model's HF checkpoint without gathering it - #2752

Open
shengliangxu wants to merge 24 commits into
shengliangx/hfep-base-12c-nogather-exportfrom
shengliangx/hfep-12c-nogather-export
Open

shengliangxu wants to merge 24 commits into
shengliangx/hfep-base-12c-nogather-exportfrom
shengliangx/hfep-12c-nogather-export

Conversation

@shengliangxu

@shengliangxu shengliangxu commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Note

The expert-parallel PTQ series is now a tree instead of one stack (re-cut 2026-10-27, from main 1521053), so PRs that do not depend on each other can be reviewed and merged in parallel. This PR was [16/21].

Base: shengliangx/hfep-base-12c-nogather-export, a branch (not a PR) that merges #2779, #2740, #2750 and #2751, the PRs this one builds on. The diff below is this PR's alone, and it merges after all of them.

Re-cut: this PR's diff is unchanged; only its base moved. Review fixes since are listed at the end of Testing.

Series layout

What does this PR do?

Type of change: New feature, with a bug fix for models built from a config

distributed_save_hf_checkpoint writes an already-processed sharded model as HF safetensors with no rank-0 gather:

No rank ever holds the whole model. On top of its own shard, which the model keeps, a rank holds what it writes; only an inference tensor is copied up front, so an export under no_grad, as hf_ptq runs it, copies nothing. A model sharded by FSDP2 alone opts in with MODELOPT_FSDP2_EXPORT=nogather; the expert-parallel export routes to it unconditionally in #2753.

Two gloo ranks exporting a 223 MiB bf16 Llama, the writer's peak RSS above each rank's 112 MiB shard:

Writer Peak RSS above the shard, per rank
copying every DTensor up front (previous revision) 278 / 246 MiB
copying only inference tensors 224 / 217 MiB
copying only inference tensors, with #2751's send-copy fix (this revision) 162 / 166 MiB

export_hf_checkpoint on this path:

  • packs quantized weights with keep_fused_experts;
  • writes config.json, a sanitized generation_config.json and hf_quant_config.json on rank 0. hf_quant_config.json names modules as the weights do: when any rank cannot reverse the conversion mapping, every rank's weights keep their in-memory names, and the quant config keeps them too (distributed_save_hf_checkpoint returns whether the names were reverted);
  • copies the source's non-model files (tokenizer, processor, remote code, chat templates), which the writer never sees.

With save_modelopt_state, it saves the ModelOpt state from rank 0, as the FSDP2 streaming export does.

The export reads how the model is sharded from its distribution plan (#2736), not from an FSDP2 module in it: the FSDP2 routes, streaming or no-gather, and which rank reads the carried-over weights. A model FSDP2-sharded with fsdp2_wrap or loaded through load_pretrained carries its plan. One wrapped by hand records it with set_distribution_plan, and without it the export raises rather than treat it as whole. A diffusers pipeline is not a module and is exported without a plan.

The KV-cache format goes through _get_kv_cache_postprocess_config, so a mixed-precision KV cache exports as on the other paths, and which writer runs is rank 0's MODELOPT_FSDP2_EXPORT.

Usage

MODELOPT_FSDP2_EXPORT=nogather torchrun --nproc_per_node 8 examples/hf_ptq/hf_ptq.py --use_fsdp2 ...

Testing

  • tests/unit/torch/export/test_distributed_writer.py adds end-to-end cases on CPU under gloo:
    • One rank:
      • an FSDP2 Mixtral written byte-identical to save_pretrained, tied and untied;
      • inside inference mode;
      • extra_state_dict kept under its own names;
      • in each of these, every dense DTensor reaching the transfer as the model's own shard, not a copy (it fails with the unconditional copy, mutation-checked);
      • export_hf_checkpoint on the no-gather path, root-wrapped and layers-only: byte-identical to the single-process export, plus config files and the copied source sidecar, for FP8 and NVFP4;
      • a Qwen2-MoE's experts exported quantized, for FP8 and NVFP4, their inputs too, so the experts' input scales and their all-reduce run on CPU. Its shared expert is packed after the experts, so experts folded outside FSDP2's weight window ([OMNIML-6081] Fold fused experts' quantizers into their weights shard-locally #2750) would be written unquantized; this replaces [OMNIML-6081] Fold fused experts' quantizers into their weights shard-locally #2750's stand-in test of that window;
      • a quantized Mixtral whose reverse conversion fails, keeping its in-memory names in exclude_modules as in the weights;
      • save_modelopt_state saved from rank 0.
    • Two ranks:
      • an uneven Mixtral matching its source, with one continuous file numbering;
      • an FP8 dense model matching the single-process export;
      • extras handed to every rank written exactly once, and an extra colliding with a model weight raising on both ranks;
      • each tensor with exactly one owner, both ranks owning some;
      • divergent dense keys, sharding and duplicate names across ranks raising instead of hanging;
      • ranks that calibrated the experts' shared input quantizer differently, every expert written with the largest scale.
    • The Mixtral-names and two-rank input-scale cases each fail without their fix (mutation-checked).
  • tests/unit/torch/export/test_unified_export_hf.py: a model built from a config, which main's Speed up CPU unit tests to stay under the 20 min CI limit #2823 gives no source checkpoint, copies nothing from the working directory at export.
  • The plan reads: reading none, taking no FSDP2 route, or dropping the pipeline guard each fails a test, the last main's test_export_diffusers_pipeline_config on a real tiny pipeline. Only rank 0 reading the carried weights is not unit-tested, since it needs two FSDP2 ranks; the GPU carry-over tests cover it.
  • Mutation-checked, across this PR and [OMNIML-6081] Fold fused experts' quantizers into their weights shard-locally #2750 and [OMNIML-6082] Add the pieces of a distributed HF checkpoint writer #2751: 76 of 77 single-point mutants killed. The survivor is a copy of plain inference tensors that nothing on CPU can reject.
  • The GPU results here were measured on earlier revisions of this branch; this revision's GPU tests run in CI only.
  • New tests/gpu/torch/export/test_distributed_hf_export.py: PTQs a tiny Qwen3 (dense, with tied embeddings, and MoE) under FSDP2 with FP8 and NVFP4, opting each export into the writer, and checks the checkpoint against the safetensors headers: every routed expert exported quantized, and the tied head written once. Its earlier version passed on a 4-GPU node, 8 tests, including checks of extras, ties, refusals and unquantized layers that now run on CPU above, where every PR runs them. On 1 GPU, all skip. Its docstring no longer describes a DCP staging step this writer never had, and an assertion on a sharded/ staging directory, which could not fail, is dropped; not re-run on GPU after that change.
  • The end-to-end cases fold into the hub-checkpoint test and the two-rank job, and tests a remaining test covers are dropped, this PR's test_export_diffusers.py test included. Each drop is checked by mutation.
  • The single-process comparison has an FP8 and NVFP4 KV-cache case, and the two-rank export sets the writer knob on rank 0 only.
  • Locally on this revision, the stack rebased on main c6fa613: pre-commit over the whole PR against main, at every PR of the stack; and at the stack's tip the full tests/unit on transformers 5.18 and 5.5 and on torch 2.8, and the hf_ptq example tests, each with the same failures as main in that environment. CI runs the full unit suite.
  • Re-cut on 2026-10-27 (see the note at the top): pre-commit, mypy included, passes at this head over the whole change against main, and the series' PRs merged together give a tree byte-identical to the former stack's tip. This PR's own unit tests pass at this head on CPU (torch 2.14, transformers 5.18); CI runs the full unit suite.
  • Review fixes, 2026-10-27: the ModelOpt state is saved from rank 0, as the streaming export does; the ranks agree on the expert input-scale keys before reducing them; the writer is told the model's merge groups. Each behaviour fix has a test that fails without it; pre-commit, mypy included, and this PR's own unit tests pass at this head.

Before your PR is "Ready for review"

  • Is this change backward compatible?: ⚠️ The no-gather writer is opt-in for FSDP2. A model FSDP2-sharded other than by load_pretrained, fsdp2_wrap included, must record its plan with set_distribution_plan before export_hf_checkpoint; without it the export raises.
  • If you copied code from any other sources or added a new PIP dependency, did you follow guidance in CONTRIBUTING.md: N/A
  • Did you write any new necessary tests?: ✅
  • Did you update Changelog?: ✅ The no-gather FSDP2 export opt-in, and, under Backward Breaking Changes, that the export and get_max_batch_size need a distributed model's plan recorded.
  • Did you get Claude approval on this PR?: ❌ Not yet.

Additional Information

Part 16 of 21 of expert-parallel PTQ for Hugging Face MoE models; merges after #2751.

Summary by CodeRabbit

  • New Features
    • Added an opt-in no-gather export for FSDP2-sharded models. With MODELOPT_FSDP2_EXPORT=nogather, ranks write checkpoint shards in parallel without gathering the full model on one host; MoE experts remain sharded.
    • Distributed exports include non-model files from the source checkpoint when available.
  • Bug Fixes
    • Export and batch-size checks now use the distribution plan recorded during model loading. Manually distributed models need a plan recorded before calibration or export; operations raise an error when a required plan is missing.

@copy-pr-bot

copy-pr-bot Bot commented Oct 12, 2026

Copy link
Copy Markdown

Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

@coderabbitai

coderabbitai Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
Walkthrough

Adds an opt-in FSDP2 no-gather Hugging Face export path. The exporter uses the distribution plan and environment setting to select the path. Distributed ranks process and write checkpoint shards. Tests cover dense and MoE exports, quantization, metadata, and multi-rank writing.

Changes: FSDP2 No-Gather Export

Layer / File(s) Summary
Export routing, packing, and configuration
modelopt/torch/export/unified_export_hf.py, CHANGELOG.rst, examples/hf_ptq/README.md
The exporter uses the distribution plan and MODELOPT_FSDP2_EXPORT=nogather to select the no-gather path. Packing can retain fused experts. The path writes base and generation configuration, and rejects save_modelopt_state=True for non-offloaded exports. The changelog and README document the option and distribution-plan requirements.
Distributed shard processing and writing
modelopt/torch/export/distributed_writer.py
Adds a writer that processes sharded state, synchronizes expert input scales, processes dense and expert weights, assigns tensors to owner ranks, and writes checkpoint shards. Rank 0 adds non-overlapping extra state.
Writer and exporter validation
tests/unit/torch/export/test_distributed_writer.py, tests/gpu/torch/export/test_distributed_hf_export.py, tests/unit/torch/export/test_unified_export_hf.py, tests/unit/torch/export/test_fused_experts_keep_fused.py
Adds CPU, multi-rank, and GPU tests for tensor contents, quantization, checkpoint metadata, sidecar files, and shard assembly. Updates source-file-copy coverage and removes earlier helper-level writer checks and the fused-expert event-order test.

Priority: Normal

Estimated code review effort: 4 (Complex) | ~60 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant unified_export_hf
  participant distributed_save_hf_checkpoint
  participant FSDP2 ranks
  unified_export_hf->>distributed_save_hf_checkpoint: packed model and export options
  distributed_save_hf_checkpoint->>FSDP2 ranks: process sharded tensors and assign owners
  FSDP2 ranks->>distributed_save_hf_checkpoint: owned tensors for checkpoint writing
Loading
Merge Risk: Moderate · up to 12267

Ordinary unsharded exports in multi-rank jobs can fail before reaching their existing export path. Preserve that path before merging. CPU-only gloo exports no longer have the previously reported CUDA-device failure.

Pre-merge checks: Passed 5
Check name Status Explanation
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Security Anti-Patterns Passed PASS. The PR changes only modelopt/torch/export/distributed_writer.py and modelopt/torch/export/unified_export_hf.py in the package; their added lines contain no `torch.load(..., weights_only=Fals…
Title check Passed The title clearly and concisely describes the main change: writing Hugging Face checkpoints for sharded models without gathering the full model.
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Full details: Security Anti-Patterns

Explanation

PASS. The PR changes only modelopt/torch/export/distributed_writer.py and modelopt/torch/export/unified_export_hf.py in the package; their added lines contain no torch.load(..., weights_only=False), pickle-enabled NumPy load, hardcoded trust_remote_code=True, eval, exec, or # nosec. The PR does not modify pyproject.toml or requirements files, and it adds no new PIP dependency.

✨ Finishing Touches: 💡 2
📝 Generate docstrings: 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
🛠️ Fix failing CI checks: 💡
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@shengliangxu
shengliangxu added this pull request to stack #2755 October 12, 2026 00:57
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-12b-writer-pieces branch from 1212af6 to 8aef85d Compare October 12, 2026 01:11
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-12c-nogather-export branch 2 times, most recently from 4810e33 to 72eab4a Compare October 12, 2026 02:44
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-12b-writer-pieces branch from 8aef85d to 6994370 Compare October 12, 2026 02:44
@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://NVIDIA.github.io/Model-Optimizer/pr-preview/pr-2752/

Built to branch gh-pages at 2026-10-27 00:05 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@codecov

codecov Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.54545% with 6 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (shengliangx/hfep-12b-writer-pieces@7a249ef). Learn more about missing BASE report.

Files with missing lines Patch % Lines
modelopt/torch/export/distributed_writer.py 96.15% 3 Missing ⚠️
modelopt/torch/export/unified_export_hf.py 90.62% 3 Missing ⚠️
Additional details and impacted files
@@                          Coverage Diff                          @@
##             shengliangx/hfep-12b-writer-pieces    #2752   +/-   ##
=====================================================================
  Coverage                                      ?   62.30%           
=====================================================================
  Files                                         ?      656           
  Lines                                         ?    73764           
  Branches                                      ?        0           
=====================================================================
  Hits                                          ?    45955           
  Misses                                        ?    27809           
  Partials                                      ?        0           
Flag Coverage Δ
unit 62.30% <94.54%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 72eab4a

@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-12c-nogather-export branch from 72eab4a to a30a335 Compare October 12, 2026 08:39
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-12b-writer-pieces branch from 6994370 to 5b2ee4a Compare October 12, 2026 08:39
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test a30a335

@shengliangxu shengliangxu changed the title [17/19] Write a sharded model's HF checkpoint without gathering it [OMNIML-6083] [17/19] Write a sharded model's HF checkpoint without gathering it Oct 12, 2026
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-12b-writer-pieces branch from 5b2ee4a to f738cd0 Compare October 13, 2026 00:53
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-12c-nogather-export branch from a30a335 to 751b1d1 Compare October 13, 2026 00:53
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-12b-writer-pieces branch from f738cd0 to 812e4e1 Compare October 13, 2026 01:32
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-12c-nogather-export branch from 751b1d1 to 717b73e Compare October 13, 2026 01:32
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 717b73e

@shengliangxu
shengliangxu removed this pull request from stack #2755 October 13, 2026 17:42
@shengliangxu
shengliangxu added this pull request to stack #2775 October 13, 2026 17:43
@shengliangxu shengliangxu changed the title [OMNIML-6083] [17/19] Write a sharded model's HF checkpoint without gathering it [OMNIML-6083] [17/20] Write a sharded model's HF checkpoint without gathering it Oct 13, 2026
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-12c-nogather-export branch from 717b73e to 446370c Compare October 13, 2026 19:38
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-12b-writer-pieces branch from 812e4e1 to 6c1cb0e Compare October 13, 2026 19:38
@shengliangxu
shengliangxu removed this pull request from stack #2775 October 13, 2026 19:38
@shengliangxu
shengliangxu added this pull request to stack #2781 October 13, 2026 19:40
@shengliangxu shengliangxu changed the title [OMNIML-6083] [17/20] Write a sharded model's HF checkpoint without gathering it [OMNIML-6083] [19/21] Write a sharded model's HF checkpoint without gathering it Oct 13, 2026
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-12c-nogather-export branch from 446370c to 755ebf2 Compare October 13, 2026 21:12
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-12b-writer-pieces branch from 6c1cb0e to a8e0c27 Compare October 13, 2026 21:12
@shengliangxu
shengliangxu removed this pull request from stack #2781 October 14, 2026 00:13

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude review — [16/21] no-gather HF checkpoint writer (fourth pass)

Scope: full review — the trigger comment was a bare /claude review with no scoping instructions. 8 files changed (+1011/−117). Reviewed in depth: modelopt/torch/export/distributed_writer.py, modelopt/torch/export/unified_export_hf.py, CHANGELOG.rst, examples/hf_ptq/README.md, and all four test files. Supporting context read outside the diff: distribution_plan/DistributionPlan/set_distribution_plan in utils/distributed.py, load_pretrained/parallel_load_and_prepare_fsdp2 in utils/plugins/model_load_utils.py, hf_export_handlers._export_weight/_export_fused_experts_module, _prepare_model_for_export, save_non_weight_artifacts, _patch_revert_weight_conversion, _materialize_owned/_write_even_shards, _undeclared_tied_aliases and _export_fsdp2_checkpoint_streaming, utils/env.py, examples/hf_ptq/hf_ptq.py:load_model. No prompt-injection or review-steering text in the diff.

Findings — CRITICAL: 0 · IMPORTANT: 1 · SUGGESTION: 1

Resolved since the previous pass

  • The last round’s CRITICAL is gone. That review ran at 00:52 against a base whose tip was 043bf553; the base was re-joined at ce1450bc (00:58) six minutes later. In this checkout the plan gate has real producers: load_pretrained records one at model_load_utils.py:681, hf_ptq.py:341 records one right after parallel_load_and_prepare_fsdp2 (which documents that it records none), and tests/gpu/torch/export/test_export_carry_over.py records one in both FSDP2 cases. The documented MODELOPT_FSDP2_EXPORT=nogather torchrun … hf_ptq.py --use_fsdp2 usage and the pre-existing FSDP2 streaming export both reach their writer. The slice stands on its own.

Most impactful

[IMPORTANT Compatibility] The save_modelopt_state refusal rests on a reason that does not hold, and its comment misdescribes the path it replaces (inline) — the FSDP2 streaming path also never calls save_pretrained for the weights, yet honours the flag with a rank-0 torch.save(modelopt_state(model), …) at unified_export_hf.py:2114; "the streaming path declines it the same way" is true only of the offload path. So flipping the env var is not a drop-in opt-in: an export that works today under --use_fsdp2 with save_modelopt_state=True becomes a hard NotImplementedError, and the CHANGELOG does not mention the dropped option. Supporting it is the same two lines the streaming path uses, in the rank == 0 block that already exists after the writer returns. Carried unaddressed from the third pass; re-raised because it is still live and the test docstring now codifies the same incorrect rationale.

[SUGGESTION] MODELOPT_FSDP2_EXPORT bypasses utils/env.py and falls back silently on an unrecognized value (inline) — =no-gather, =NOGATHER, =1 all quietly select the gather/streaming writer, i.e. the path the user set the knob to avoid. env.py exists for exactly these knobs and its siblings validate their values.

Carried over from earlier passes — not re-filed. Listed for completeness only; each was raised twice and none is blocking.

  • Dead EP scaffolding in _distributed_save_hf_checkpoint_impl (ep_rank/dp_idx pinned to 0 so write_experts is always true and total_experts inert, since add_ep_base requires bool(ep_rank)); the comment "dp_idx is the EDP-group rank resolved above" describes a resolution that does not happen. #2753 can bring these with values that make them live.
  • _write_base_config is save_non_weight_artifacts minus its last line; a copy_source_files=False keyword on the existing helper avoids the fork and simplifies the rank == 0 tail condition. (The dropped _patch_revert_weight_conversion wrapper does check out — nothing reachable from config.save_pretrained calls revert_weight_conversion.)
  • The expert _input_scale all_reduce is sized from per-rank keys behind if in_keys:, ahead of _materialize_owned’s divergence check — the one collective with no cross-rank agreement check. Unreachable under the is_fsdp_dense plan this writer targets.
  • Unused _tied from the _prepare_model_for_export unpack.

What checked out this pass

  • Dense weights are not quantized from a local shard. _export_weight and _export_fused_experts_module both open _fsdp2_weight_window when ctx.keep_fused_experts and distribution_plan(ctx.model).is_fsdp_dense, so the packer sees whole weights and a per-tensor weight amax cannot diverge per rank. The relaxed assert not is_fsdp2_model(model) or keep_fused_experts in _process_quantized_modules is consistent with that.
  • The _to_normal memory finding from the third pass is fixed: the DTensor branch is now guarded by _loc.is_inference(), so a no_grad caller (hf_ptq) copies nothing and peak is no longer the shard twice. The from_local(shape=…, stride=…) rewrap is still right for uneven and zero-size shards.
  • Undeclared ties are a deliberate difference, not a gap. _write_even_shards clones rather than drops an aliased name ("every name has to reach the index"), so unlike save_pretrained/the streaming path this writer keeps both names. I went looking for a safetensors shared-memory failure here; the ptrs set at distributed_writer.py:563 already covers it, and declared ties are dropped by TiedWeightMap consistently on every rank.
  • The input-scale reduce runs on _input_scale values that are plain tensors (registered after fully_shard, so FSDP2 never wraps them), which is what makes dist.all_reduce on the stack correct; amax/448 is monotonic, so reducing the post-fold scale equals reducing the amax, and no weight is quantized against a scale the checkpoint does not carry.
  • broadcast_from is short-circuited by is_fsdp2_sharded, which comes from the recorded plan and is therefore identical on every rank; the save_modelopt_state refusal is raised before pack_quantized_weights mutates anything and before the try, so a refusal leaves the model untouched and no rank stranded in a collective; the extra_state_dict collision check runs inside all_reduce so rank 0’s finding raises everywhere.
  • _write_base_config matches save_non_weight_artifacts on config dtype (both write model.config as-is), so the no-gather config.json does not diverge from the streaming paths’. File-write ordering is safe: copy_off_index_safetensors skips in-index names and _copy_non_model_files_from_source skips files the export wrote, so neither can clobber the shards or the embedded quantization_config.
  • _writes_extra, rank and the _copy_non_model_files_from_source tail condition all follow the plan consistently; a replicated multi-rank export keeps its pre-existing "every rank writes everything" behaviour.
  • Tests: the two-rank job’s ordering is deliberate and correct (the rank-0 byte comparison is last, after every collective, so a failing assert strands nobody); the mixed-precision KV case (_FP8_MIXED_KV_CFG) covers the _get_kv_cache_postprocess_config route end to end; the GPU test reads safetensors headers directly, halves the packed last dim for U8, and asserts every routed expert is quantized rather than just one. Dropping test_fused_experts_fold_in_a_weight_window… in favour of the Qwen2-MoE CPU case trades a monkeypatched call-sequence assertion for the observable effect — a net gain.
  • PR size within budget: 355 insertions outside tests/ and docs/. CHANGELOG entries are user-facing, one or two sentences, and filed under the right sub-sections.

Assessment — risk: low-moderate. The writer stays opt-in and the default export is untouched, the plan-gate dependency that dominated the last pass is now satisfied inside this slice, and the structural failure modes (dropped key, mis-assembled shard, divergent ranks, duplicate name, missing index) are each asserted or explicitly refused. What is left is one behavioural delta against the path this writer substitutes for — a supported option becoming an exception, with a comment that states the opposite of what the sibling path does — and one knob-hygiene suggestion. Neither threatens checkpoint correctness; the first is worth closing because the env var is advertised as a drop-in.

🤖 Generated with Claude Code

distributed_save_hf_checkpoint writes an already-processed sharded model as
HF safetensors with no rank-0 gather: fused experts are split shard-locally,
every other tensor moves whole to one owner rank, the reverse conversion runs
there, and each rank writes its own shards. No rank holds the whole model. A
model sharded by FSDP2 alone opts in with MODELOPT_FSDP2_EXPORT=nogather; the
expert-parallel export routes to it in the next change.

On that path export_hf_checkpoint packs with keep_fused_experts; writes
config.json, a sanitized generation_config.json and hf_quant_config.json on
rank 0; and copies the source's non-model files (tokenizer, processor, remote
code, chat templates), which the writer never sees. It rejects
save_modelopt_state, which the path cannot honor without save_pretrained,
rather than drop it silently.

A model built from a config has an empty config._name_or_path, which as a
path is the working directory, so the export copied the working directory's
tokenizer, processor and code files as if they were the model's. An empty
name now means no source checkpoint.

The output matches the single-process export tensor for tensor (CPU tests:
FP8 and NVFP4, one and two ranks, root-wrapped and layers-only FSDP2). A GPU
test PTQs a tiny Qwen3 (dense and MoE) under FSDP2 with FP8 and NVFP4,
opting each export into the writer, and checks the checkpoint against the
safetensors headers.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
export_hf_checkpoint takes the FSDP2 routes, streaming or no-gather, when the model's recorded plan
FSDP2-shards it, and reads the carried-over weights on rank 0 alone for the same reason, rather than
looking for an FSDP2 module. A diffusers pipeline is not a module and is never distributed, so it is
exported without a plan, as a new test checks. The writer tests record the plan where they shard a
model with fully_shard themselves.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
fsdp2_wrap records no plan; load_pretrained does. These tests wrap the
model by hand, and the export picks its route from the plan, so each
records the FSDP2 plan it wrapped the model by.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Batch sizing and the HF export read how a model is distributed from the
plan load_pretrained records, and raise for an FSDP2-sharded or
multi-rank model without one, where they used to look at the model and
the process group. Users who distribute a model themselves, fsdp2_wrap
included, now record its plan.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
main opened 0.49.0 for what merges after 0.48.0's cut, which this PR does.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
It spawned two ranks, about 14 s of the linux unit job.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…iter test

The writer now raises it on every rank, so rank 0 no longer has to release
rank 1 from the closing barrier; the dedicated test bounds that case, so the
two-rank job no longer repeats it.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The window around the fold was guarded only by a test of two stubs' call
order. The MoE no-gather export test used Qwen3-MoE, whose layers pack
nothing after their experts, so it exported them quantized with or
without the window. The window matters when a dense weight in the same
FSDP2 unit is packed after the fold: that weight's own window unshards
and reshards the unit, which puts FSDP2's original shards back over a
fold FSDP2 does not track. Qwen2-MoE's shared expert is such a weight,
so the test now exports Qwen2-MoE, which fails without the window, and
the stub test goes.

The GPU MoE export now requires every expert to be exported quantized,
not just some weight.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Every GPU test of the no-gather export needs two GPUs, which the PR
runner does not have. What they checked runs on CPU instead, through
the same writer and export. The two-rank write is now handed tensors
the model does not own on every rank, and must write them once. A
linear left unquantized already is checked: every no-gather
equivalence case exports the lm_head unquantized, byte for byte as the
single-process export does. The GPU completeness test ties the dense
model's embeddings, so its expected keys also check that the alias is
dropped. The GPU tests these cover go, with the GPU refusal of
save_modelopt_state, which the unit test of the same refusal covers.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…vered ones

Merged:
- The inference-mode and extra_state_dict writes become cases of
  test_distributed_save_writes_the_hub_checkpoint_of_a_sharded_moe.
- test_a_model_built_from_a_config_names_no_source_checkpoint folds into
  test_source_checkpoint_is_none_for_a_model_built_from_config, which now also
  checks that nothing is copied from the working directory.
- test_materialize_owned_leaves_every_tensor_whole_and_plain moves into the
  two-rank job, where ownership is real: its tensors cycle through dim-0 shards,
  replicated DTensors and plain tensors, plus a zero-row tensor. Dropping,
  duplicating or not unwrapping any of them, treating experts as dense, or
  writing the zero-row tensor as float32 fails the job.

Dropped, each caught by a remaining test when its code is broken:
- test_revert_whole_sd_keeps_a_state_dict_with_nothing_to_revert: wiping a dense
  state dict fails test_nogather_export_writes_what_the_single_process_export_writes.
- test_revert_whole_sd_renames_to_hub_names_in_place: discarding the reverse
  fails the hub-checkpoint test.
- test_a_pipeline_is_exported_without_a_distribution_plan: reading a pipeline's
  plan fails test_export_diffusers_pipeline_config on a real tiny pipeline.

The two-rank job checks the consistency errors with try/except inside
bounded_collectives rather than pytest.raises, which takes the shared worker
down when nothing is raised.

Kept, since no remaining test fails when their code is broken: the split's
storage, dense pass-through and prefix tests and the handler test in
test_fused_experts_keep_fused.py, the mesh-memo test, and
test_write_even_shards_writes_even_shards_and_an_index_of_every_tensor, the
only check that written tensors leave the rank's set, of the file count, and of
a non-contiguous tensor.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The no-gather writer took its device from torch.cuda.current_device() on
every call, though only the expert input-scale all-reduce needs it. On CPU
torch that raised, so every writer unit test patched current_device. Take
the device from the backend: the CPU under a gloo-only group, the current
CUDA device otherwise (distributed_run sets up cpu:gloo,cuda:nccl). The tests
drop the patch, and fail on CPU torch without the change.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
distributed_save_hf_checkpoint checked extra_state_dict against the model's
keys on rank 0 only, outside any collective. A collision raised on rank 0
while the other ranks went on into the write's first collective and waited
there until the timeout, then raised an unrelated timeout. Run the check inside
all_reduce, so every rank raises rank 0's error at once. The two-rank writer
job covers it, and now checks rank 0's checkpoint bytes after its last
collective, so a failed check cannot leave rank 1 waiting either.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
_write_hf_export_config rewrote generation_config.json, which every export
route has already written by then: save_pretrained on the plain path,
save_non_weight_artifacts on the streaming paths and _write_base_config,
behind can_generate(), on the no-gather path. The layerwise path returns
before it. Main's helper never wrote it.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…r not

The overlap check ran its all_reduce only where extra_state_dict was non-empty,
but a sharded export hands the tensors it carries over (an MTP head, an
auxiliary tower) to rank 0 alone. Rank 0 then entered the overlap collective
while the others went on to "shard counts", and every sharded export carrying
weights failed with "Ranks reduced out of step". Every rank joins now; only
rank 0, which holds the extras, checks and adds them. The two-rank writer job
hands its extras to rank 0 only, as the export does. Found by the epdp port.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The comment credited is_fsdp2_model with choosing the writer; the
recorded plan and the MODELOPT_FSDP2_EXPORT opt-in choose it. The
assertion stays: it checks the model is really sharded, so the test
exercises the shard writer.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…rank 0's writer choice

The no-gather export passed the raw kv_cache_quant_algo to the writer, so a mixed-precision KV
cache reached postprocess_state_dict as the "MIXED_PRECISION" sentinel and failed its format
assert. It now resolves the per-layer map through _get_kv_cache_postprocess_config, as the gather
and streaming exports do; the single-process comparison covers an FP8 and NVFP4 KV cache.

Which writer runs is rank 0's MODELOPT_FSDP2_EXPORT, broadcast: the two writers issue different
collectives. The two-rank export sets the knob on rank 0 only. The no-gather branch drops a
save_modelopt_state write it refused earlier, and _write_base_config says why a config save needs
no revert_weight_conversion patch.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The no-gather writer keeps the in-memory tensor names on every rank when any rank cannot reverse
the conversion mapping (a merge group split across ranks), but export_hf_checkpoint renamed the
quant config to hub names regardless, so exclude_modules named modules the checkpoint did not
hold and a loader would read an excluded layer as quantized. distributed_save_hf_checkpoint now
returns whether the names were reverted, and the config is renamed only when they were.

The experts' input-scale all-reduce runs on the scales' own device instead of one picked from the
backend name, and the CPU tests now cover it: the one-rank Qwen2-MoE export quantizes expert
inputs (its comment called the reduce CUDA-only), and a two-rank export whose ranks calibrated
different input amaxes must write the largest on every expert.

Also drops a comment that described no code, and the GPU test's description of a staging step
this writer never had, with the assertion that nothing left that staging directory behind.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The no-gather writer copied every DTensor's local shard up front to drop the inference flag,
though only an inference tensor carries it, and its comments called the copy free: the state
dict's values are views of the model's own parameters, so each copy adds to the rank's memory
instead of replacing a value. A DTensor is now copied only when its local shard is an inference
tensor, as a plain tensor already was, so a caller outside inference mode (hf_ptq exports under
no_grad) copies nothing. On two gloo ranks exporting a 223 MiB bf16 Llama, the writer's peak above
each rank's 112 MiB shard drops from 278 and 246 MiB to 224 and 217 MiB. The docstrings and
comments now state the bound the code has.

Also drops an unused loop index.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Since [8/21], get_max_batch_size measures a model with no recorded plan per rank, as it did before,
so only export_hf_checkpoint raises for a sharded or multi-rank model that carries no plan.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…xport does

The no-gather FSDP2 writer refused save_modelopt_state=True, so opting into it broke an export the
default FSDP2 path completes. That path saves the state from rank 0 with torch.save, not through
save_pretrained, and every FSDP2 rank holds the same state: the no-gather path now does the same.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The writer max-reduces the experts' shared input scales over keys each rank derives from its own
state dict. A rank holding none skipped the reduce and left the others waiting in it, and lists of
different lengths mismatched its size, with nothing naming the cause. The ranks now agree on the
keys first, through the labeled all_reduce the writer's other checks use, and a difference fails on
every rank, naming the keys.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The writer pieces now place a merge group with one owner when they are told the model's groups,
but the export did not tell them: a layer's q, k and v could land on different ranks, the merge
then failed, and the whole checkpoint kept its in-memory names.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Dropping the stub window test took the module import with it, but the keep-fused fold's window-per-unit test still drives the handlers through it.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test f932fb2

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/claude review

Comment on lines +1909 to +1928
def _write_base_config(model: nn.Module, export_dir: "Path | str") -> None:
"""Write config.json + generation_config for the no-gather distributed export path.

``distributed_save_hf_checkpoint`` writes only the weight shards, so rank 0 emits here what
``save_pretrained`` would have. The weight half of ``save_pretrained`` must NOT run: the
weights are already on disk and the model may still be sharded.
"""
# A config's save_pretrained writes only config.json: transformers reverts weight conversions
# in the model's, so the patch save_non_weight_artifacts takes is not needed here.
model.config.save_pretrained(export_dir)
if model.can_generate() and getattr(model, "generation_config", None) is not None:
try:
# Same strict-validate trip as every other save site: a config carrying top_p/top_k
# with do_sample unset (e.g. GLM-5.3-BF16) makes save_pretrained raise, and the
# export would then carry the source's generation_config.json instead of this one.
_sanitize_generation_config_for_save(model)
model.generation_config.save_pretrained(export_dir)
except Exception as gen_err:
warnings.warn(f"Could not save generation_config: {gen_err}")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION] _write_base_config is a near-copy of the existing save_non_weight_artifacts (same file, L1607), which the streaming and layerwise exporters already use: config → generation_config → copy the source's non-model files. The only differences are the can_generate() guard, a warning instead of a silent suppress, and that it stops short of the copy — which this path then performs separately via the widened guard at L2182.

Reusing it would collapse both the helper and that guard change:

elif use_no_gather_writer:
    ...
    if rank == 0:
        save_non_weight_artifacts(model, export_dir)
        ...

and leave L2182 as the original if not (_offloaded or is_fsdp2_sharded):.

Ordering is safe: copy_non_model_files documents that it skips any file the export already wrote, so the source's config.json / generation_config.json are not copied over the ones just emitted, and _write_hf_export_config still rewrites config.json in place afterwards.

If the can_generate() guard and the visible warning are the point, the cheaper move is to add them to save_non_weight_artifacts so all four export paths get them, rather than keeping a second helper that drifts from it. Per CONTRIBUTING/CLAUDE.md's "extend an existing mechanism over adding a parallel one". Non-blocking.

Comment on lines +765 to +767
if in_keys:
stacked = torch.stack([sharded_sd[k].detach().reshape(()).float() for k in in_keys])
dist.all_reduce(stacked, op=dist.ReduceOp.MAX)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION] The new key agreement above closes the divergence that mattered, but the two lines that follow are still the one place in this function where a rank-local failure can strand its peers in a collective.

sharded_sd[k].detach().reshape(()) raises RuntimeError: shape '[]' is invalid for input of size N for any non-scalar _input_scale (_split_local_fused_module documents it as a "shared scalar", but nothing enforces that here). If the shape is rank-dependent — a per-channel input quantizer that collapsed to a scalar on one rank, a remote-code MoE whose shared input quantizer is shaped differently per shard — that rank raises here while the others sit in dist.all_reduce until the PG timeout, which says nothing about the real cause. Same for a rank whose scales sit on a different device than its peers'.

Everything else in this writer is deliberately structured so a single-rank failure becomes an error on every rank (all_reduce("reverse weight conversion", ...), all_reduce("extra_state_dict overlap", ...), the six gathered-metadata refusals in _materialize_owned). The cheapest way to hold that line here is to fold the shapes into the agreement you already do, e.g. gather [(k, tuple(sharded_sd[k].shape), str(sharded_sd[k].device)) for k in in_keys] instead of the bare key list, and refuse a non-scalar or a device mismatch with a named error before anyone enters dist.all_reduce. Low likelihood, non-blocking.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude review — [16/21] No-gather sharded HF checkpoint write (re-review after the 2026-10-27 re-cut)

Scope: full review (the trigger comment carried no scoping instructions). 8 changed files, +1076/−117. Reviewed modelopt/torch/export/distributed_writer.py and modelopt/torch/export/unified_export_hf.py in full, plus examples/hf_ptq/README.md, CHANGELOG.rst, and all four test files (test_distributed_writer.py, test_unified_export_hf.py, test_fused_experts_keep_fused.py, and the new tests/gpu/torch/export/test_distributed_hf_export.py). Pulled in for context: _materialize_owned / _merge_groups / _revert_whole_sd / _write_even_shards from the base branch, moe_utils._split_local_fused_module, hf_export_handlers._export_weight / _export_fused_experts_module / _hold_fsdp2_weight_window, quant_utils.postprocess_state_dict / _get_kv_cache_postprocess_config, utils/distributed.DistributionPlan / broadcast_from / all_reduce, save_non_weight_artifacts, _write_hf_export_config and copy_non_model_files.

Findings — CRITICAL: 0 · IMPORTANT: 0 · SUGGESTION: 2 (both new)

  1. unified_export_hf.py:1909-1928 — _write_base_config is a near-copy of the existing save_non_weight_artifacts; reusing it would also let the widened guard at L2182 revert to its original form.
  2. distributed_writer.py:765-767 — reshape(()) on the expert input scales sits outside any all_reduce wrapper, so a rank-dependent scale shape or device raises on one rank while its peers wait in dist.all_reduce. Folding the shapes into the key agreement you just added closes it.

Both are non-blocking.

Prior review (2026-10-25) — status of its three suggestions

  • Input-scale reduce ordering — fixed. The new all_reduce("expert input-scale keys", ...) makes every rank agree on the key list before the reduce, and a difference now fails on every rank naming the keys. The remaining gap is finding 2 above, which is the narrower residue of the same concern.
  • EP scaffolding (ep_rank / dp_idx / total_experts / write_experts) — unchanged, and I am withdrawing the dead-code reading: the next PR in the series (#2753) is the expert-parallel export that sets ep_rank, so this is the seam it lands on rather than leftover. The one thing still worth a one-line fix is the comment "dp_idx is the EDP-group rank resolved above", which describes a resolution that does not happen on this path.
  • get_rank() without is_initialized() (L1981, L2026) — unchanged. Only reachable by recording dense="fsdp" in a single process, so I am not re-raising it.

What I checked this round

  • The two new behaviour fixes. Rank 0's modelopt_state save mirrors the FSDP2 streaming branch exactly (same guard, same filename, same rank), so the two paths cannot drift. _merge_groups(model, local_sd) is passed the full dict, experts included, but _materialize_owned filters each group through unit_of, which holds dense keys only; expert-only groups collapse to () and never enter unit_bytes, and a mixed group reduces to its dense members — identical on every rank, since the dense key set is proven identical by the gathered metadata. An expert-side merge (experts.<i>.gate_proj + up_proj) is already co-located by construction, so nothing is lost by the filter.
  • Packing a sharded model. The relaxed _process_quantized_modules assertion opens the door for dense modules too, not only fused experts — but _export_weight opens an fsdp2_aware_weight_update window per FSDP2 unit whenever keep_fused_experts and plan.is_fsdp_dense, so dense weights are whole when packed. That is what keeps NVFP4's weight_scale_2 (a weight-amax reduction) from being computed over a local shard and silently mis-scaling the concatenated tensor. Experts stay Shard(0) and are folded in the unit's window, so FSDP2's next reshard cannot undo the fold.
  • Collective symmetry end to end. use_no_gather_writer comes from broadcast_from on rank 0, so the two writers' different collective sequences cannot be mixed; the two-rank test sets the knob on rank 0 only. Within the writer, the key agreement, _materialize_owned's single metadata gather, _revert_whole_sd, the extra_state_dict overlap check and _write_even_shards's closing all_reduce are all unconditional and all route single-rank failures through modelopt.torch.utils.distributed.all_reduce. A rank-0-only failure in _write_base_config or _write_hf_export_config still reaches the finally: barrier(), so it raises rather than hangs.
  • Checkpoint completeness vs save_pretrained. config.json + generation_config.json from _write_base_config, the index from _write_even_shards, hf_quant_config.json and the embedded quantization_config from _write_hf_export_config, tokenizer/processor/remote code/chat templates from _copy_non_model_files_from_source. The guard not (_offloaded or (is_fsdp2_sharded and not use_no_gather_writer)) evaluates correctly for all four path combinations, and the copy runs after the config writes, which copy_non_model_files documents as required. The ordering difference for _sanitize_generation_config_for_save (after config.save_pretrained here, before it in save_non_weight_artifacts) is harmless — it touches model.generation_config only.
  • Quant-config / weight-name agreement. distributed_save_hf_checkpoint returning reverted, with _revert_quant_config_names_best_effort gated on it, is the tighter of the two conventions in this file (the streaming branch calls it unconditionally), and it is what keeps exclude_modules naming modules the way the weights do when the reverse is all-or-nothing refused.
  • KV cache and dtypes. _get_kv_cache_postprocess_config is the same helper the other three HF exporters use, so MIXED_PRECISION resolves to the per-layer map here too; maxbound=448 matches the sibling call sites. The reduced scalars land as 0-dim float32, which is what the export handlers register.
  • Backward compatibility. The plan requirement is the one break, and it is in CHANGELOG.rst under Backward Breaking Changes with the set_distribution_plan migration named. I traced every export_hf_checkpoint caller: hf_ptq.py records the plan right after parallel_load_and_prepare_fsdp2, the diffusers callers pass a pipeline (not an nn.Module, so the plan read is skipped), and the llm_sparsity callers are single-process. The new keep_fused_experts keyword defaults to the old behaviour on both pack_quantized_weights and _process_quantized_modules.
  • Tests. The new unit cases run under the session-scoped cpu_dist_workers fixture, which CLAUDE.md exempts from the no-subprocess rule; CodeRabbit's two earlier notes are both addressed (the rank-0 byte-identical assertion is now explicitly last, "after every collective", and the test import moved to module scope). The divergence cases run under bounded_collectives(), so a regression fails fast instead of hanging CI. The GPU test reads safetensors headers directly rather than loading, skips cleanly under 2 GPUs, and its tie_word_embeddings case catches a doubly-written lm_head.weight through the unexpected assertion. The deleted weight-window test is justified and subsumed by the Qwen2-MoE end-to-end case.
  • PR hygiene. 359 insertions under modelopt/ + examples/, inside the ~500-line budget. No prompt-injection-style text anywhere in the diff.

Risk: low. The new writer is opt-in behind MODELOPT_FSDP2_EXPORT=nogather and the default FSDP2 export is untouched; the shared-code changes are additive with old defaults. Both this round's findings are diagnostics and simplicity, not behaviour, and the three fixes since the last review each closed a real asymmetry. Approving.

🤖 Generated with Claude Code

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant